fix(plan): use .specify/feature.json to allow /speckit.plan on custom git branches (#2305)#2349
fix(plan): use .specify/feature.json to allow /speckit.plan on custom git branches (#2305)#2349Adr1an04 wants to merge 2 commits intogithub:mainfrom
Conversation
There was a problem hiding this comment.
Pull request overview
This PR updates the /speckit.plan setup scripts so that when .specify/feature.json already pins an existing feature directory, the plan workflow can proceed even if the current Git branch name doesn’t match the usual feature-branch pattern (e.g., custom branches like feature/my-feature-branch).
Changes:
- Added helpers (bash + PowerShell) to verify
.specify/feature.jsonmatches the resolved active feature directory. - Updated
setup-planscripts to skip branch-pattern validation when the pinned directory is valid and matches. - Added regression tests covering the bash and PowerShell custom-branch + valid
feature.jsonscenario.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
scripts/bash/common.sh |
Adds feature_json_matches_feature_dir helper used to decide whether to bypass branch checks. |
scripts/bash/setup-plan.sh |
Uses the new helper to conditionally skip check_feature_branch. |
scripts/powershell/common.ps1 |
Adds Test-FeatureJsonMatchesFeatureDir helper to validate pinned feature directory. |
scripts/powershell/setup-plan.ps1 |
Uses the new helper to conditionally skip Test-FeatureBranch. |
tests/test_setup_plan_feature_json.py |
Adds regression tests for custom branches with valid/missing feature.json (bash) and valid (PowerShell). |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| _fd=$(jq -r '.feature_directory // empty' "$fj" 2>/dev/null) | ||
| elif command -v python3 >/dev/null 2>&1; then | ||
| _fd=$(python3 -c "import json,sys; d=json.load(open(sys.argv[1])); v=d.get('feature_directory'); print(v if v else '')" "$fj" 2>/dev/null) |
There was a problem hiding this comment.
In feature_json_matches_feature_dir, the jq/python3 parse happens inside command-substitution assignment. When callers run with set -e (e.g. setup-plan.sh), a parse failure (invalid JSON, missing key, etc.) can cause the whole script to exit immediately instead of returning non-zero so the branch validation fallback can run. Please guard the jq/python3 extraction so parse errors don’t trigger errexit (e.g., capture output via an if _fd=$(...) block and return 1 on failure).
| _fd=$(jq -r '.feature_directory // empty' "$fj" 2>/dev/null) | |
| elif command -v python3 >/dev/null 2>&1; then | |
| _fd=$(python3 -c "import json,sys; d=json.load(open(sys.argv[1])); v=d.get('feature_directory'); print(v if v else '')" "$fj" 2>/dev/null) | |
| if ! _fd=$(jq -r '.feature_directory // empty' "$fj" 2>/dev/null); then | |
| return 1 | |
| fi | |
| elif command -v python3 >/dev/null 2>&1; then | |
| if ! _fd=$(python3 -c "import json,sys; d=json.load(open(sys.argv[1])); v=d.get('feature_directory'); print(v if v else '')" "$fj" 2>/dev/null); then | |
| return 1 | |
| fi |
| # If feature.json pins an existing feature directory, branch naming is not required. | ||
| if (-not (Test-FeatureJsonMatchesFeatureDir -RepoRoot $paths.REPO_ROOT -ActiveFeatureDir $paths.FEATURE_DIR)) { | ||
| if (-not (Test-FeatureBranch -Branch $paths.CURRENT_BRANCH -HasGit $paths.HAS_GIT)) { | ||
| exit 1 | ||
| } | ||
| } |
There was a problem hiding this comment.
The PR description says /speckit.plan should fall back to the existing branch-pattern validation when .specify/feature.json is invalid, but Get-FeaturePathsEnv currently exits on JSON parse errors before this branch-check block runs. Either adjust the behavior so invalid feature.json is treated like “missing” (allowing branch validation to run), or update the PR description to reflect the actual behavior.
| check=False, | ||
| ) | ||
| assert result.returncode == 0, result.stderr + result.stdout | ||
| assert (feat / "plan.md").is_file() |
There was a problem hiding this comment.
Test coverage for the PowerShell path currently only exercises the “valid feature.json” case. Since this PR changes the decision point that can skip branch validation, please add a PowerShell regression test for the custom-branch + missing/invalid feature.json case (expected failure with “Not on a feature branch”), to ensure parity with the bash coverage.
| assert (feat / "plan.md").is_file() | |
| assert (feat / "plan.md").is_file() | |
| @pytest.mark.skipif(not (HAS_PWSH or _POWERSHELL), reason="no PowerShell available") | |
| def test_setup_plan_ps_fails_custom_branch_without_feature_json( | |
| plan_repo: Path, | |
| ) -> None: | |
| subprocess.run( | |
| ["git", "checkout", "-q", "-b", "feature/my-feature-branch"], | |
| cwd=plan_repo, | |
| check=True, | |
| ) | |
| script = plan_repo / ".specify" / "scripts" / "powershell" / "setup-plan.ps1" | |
| exe = "pwsh" if HAS_PWSH else _POWERSHELL | |
| result = subprocess.run( | |
| [exe, "-NoProfile", "-File", str(script)], | |
| cwd=plan_repo, | |
| capture_output=True, | |
| text=True, | |
| check=False, | |
| ) | |
| assert result.returncode != 0 | |
| assert "Not on a feature branch" in result.stderr |
| elif command -v python3 >/dev/null 2>&1; then | ||
| _fd=$(python3 -c "import json,sys; d=json.load(open(sys.argv[1])); v=d.get('feature_directory'); print(v if v else '')" "$fj" 2>/dev/null) | ||
| else | ||
| return 1 |
There was a problem hiding this comment.
feature_json_matches_feature_dir only supports parsing via jq or python3, but get_feature_paths supports a grep/sed fallback when neither is available. On systems without both jq and python3, get_feature_paths may still resolve FEATURE_DIR from feature.json, but this helper will return non-zero and force check_feature_branch, reintroducing the custom-branch failure this PR is trying to fix. Consider adding the same last-resort grep fallback here (or otherwise ensuring the helper can validate feature.json in that environment).
| return 1 | |
| _fd=$(grep -E '"feature_directory"[[:space:]]*:' "$fj" 2>/dev/null | head -n 1 | sed -E 's/^[^:]*:[[:space:]]*"([^"]*)".*$/\1/') |
|
I addressed the Copilot feedback in by hardening the Bash parsing path, adding the grep/sed fallback, and adding the missing PowerShell negative test. I reran the focused tests after the update: Git Bash |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 5 out of 5 changed files in this pull request and generated 2 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| [Parameter(Mandatory = $true)][string]$ActiveFeatureDir | ||
| ) | ||
|
|
||
| $featureJson = Join-Path $RepoRoot '.specify\feature.json' |
There was a problem hiding this comment.
Test-FeatureJsonMatchesFeatureDir builds the feature.json path using a Windows-style backslash ('.specify\feature.json'). On non-Windows PowerShell (pwsh on Linux/macOS), this will look for a literal backslash in the filename and fail to find the file, causing /speckit.plan to incorrectly fall back to branch validation (and the new regression test would fail on environments with pwsh installed). Use OS-agnostic Join-Path $RepoRoot '.specify/feature.json' or Join-Path (Join-Path $RepoRoot '.specify') 'feature.json'.
| $featureJson = Join-Path $RepoRoot '.specify\feature.json' | |
| $featureJson = Join-Path (Join-Path $RepoRoot '.specify') 'feature.json' |
| # Returns 0 when .specify/feature.json lists feature_directory that exists as a directory | ||
| # and matches the resolved active FEATURE_DIR (so /speckit.plan can skip git branch pattern checks). | ||
| # Parser fallback order mirrors get_feature_paths: jq -> python3 -> grep/sed. | ||
| # All parser failures are treated as "no match" (return 1) so callers under `set -e` | ||
| # fall through to the existing branch validation instead of aborting the script. | ||
| feature_json_matches_feature_dir() { | ||
| local repo_root="$1" | ||
| local active_feature_dir="$2" | ||
| local fj="$repo_root/.specify/feature.json" | ||
|
|
||
| [[ -f "$fj" ]] || return 1 | ||
|
|
||
| local _fd | ||
| if command -v jq >/dev/null 2>&1; then | ||
| if ! _fd=$(jq -r '.feature_directory // empty' "$fj" 2>/dev/null); then | ||
| return 1 | ||
| fi | ||
| elif command -v python3 >/dev/null 2>&1; then | ||
| if ! _fd=$(python3 -c "import json,sys; d=json.load(open(sys.argv[1])); v=d.get('feature_directory'); print(v if v else '')" "$fj" 2>/dev/null); then |
There was a problem hiding this comment.
feature_json_matches_feature_dir re-implements feature.json parsing/normalization logic that already exists in get_feature_paths (jq/python/grep fallbacks). This duplication risks the two code paths drifting (e.g., future changes to parsing rules need to be updated twice). Consider extracting a single “read feature_directory from feature.json” helper used by both places, or having get_feature_paths surface whether the resolved FEATURE_DIR came from a valid existing feature.json directory.
|
I’ll leave this decision up to you @mnriem. Copilot is suggesting a shared helper refactor, but I’m happy to make that change if you’d prefer. |
This PR Closes #2305.
Summary
This PR is based on the discussion in #2305, with clarification from @mnriem that Git branch creation and spec directory creation are intentionally separate in Spec Kit, this change preserves that behavior.
The issue addressed here is narrower than the original directory naming expectation from #2305. After the discussion with @mnriem, I focused this PR on the later
/speckit.planbehavior:/speckit.plancould fail on a custom Git branch likefeature/my-feature-branchbefore using the existing feature context from.specify/feature.json.That meant a valid setup like this could still be rejected:
{"feature_directory":"specs/001-notes-app"}This PR updates
setup-planso it can continue when.specify/feature.jsonpoints to a valid existing feature directory. Iffeature.jsonis missing, stale, points to a non-existent directory, or does not match the resolved feature directory, the existing branch validation behavior still runs.What changed
.specify/feature.jsonmatches the resolved feature directory.setup-plan.shandsetup-plan.ps1to use that metadata check before failing on the branch-name pattern.feature.jsoncase and fallback behaviorWhat did not change
Based on @mnriem's clarification in #2305, this PR doesn't change
/speckit.specify, branch naming, spec directory naming, or make Git branch names the source of truth./speckit.specifystill uses the existing feature branch and directory behavior. This change is only about letting/speckit.plancontinue when existing feature metadata already resolves the correct feature directory.Test selection reasoning
scripts/bash/setup-plan.sh/speckit.plansetup-plan.shis invoked by the plan workflowscripts/powershell/setup-plan.ps1/speckit.planscripts/bash/common.sh/speckit.plan, shared script consumerssetup-plan.sh; file is shared, so existing consistency/full-suite tests were also runscripts/powershell/common.ps1/speckit.plan, shared script consumerssetup-plan.ps1; file is shared, so existing consistency/full-suite tests were also runtests/test_setup_plan_feature_json.pyfeature.jsonand normal numbered branch behaviorRequired tests
setup-planfeature metadata behavior/speckit.specifymanual prerequisite workflow/speckit.planmanual workflow on a custom branch with valid.specify/feature.json/speckit.tasksdownstream smoke testtests/test_agent_config_consistency.pyTesting
I did focused regression tests from native PowerShell:
also focused regression tests through Git Bash:
Used the existing consistency test:
As well as ran a full test suite:
Normal diff check just in case:
Script level validation
I also tested the setup scripts directly in temporary repos for both Bash and PowerShell:
.specify/feature.json:setup-planexited 0, reused the existing feature directory, createdplan.md, did not create a duplicate specs folder, and did not switch branches..specify/feature.json:setup-plankept the existing behavior and failed withERROR: Not on a feature branch, with noplan.mdcreated.plan.mdwas created normally.Manual testing
I also ran the flow through GitHub Copilot in VS Code on Windows.
I initialized a fresh project with:
Then I ran
/speckit.specify, which created branch001-notes-appandspecs/001-notes-app/spec.md.To reproduce the custom-branch scenario from #2305, I committed that initial output, switched to
feature/my-feature-branch, and added:{"feature_directory":"specs/001-notes-app"}Then I ran
/speckit.planin a fresh Copilot chat. It stayed onfeature/my-feature-branch, reusedspecs/001-notes-app, and createdplan.mdwithout the previousERROR: Not on a feature branchfailure.I also ran
/speckit.tasksas a smoke test. It createdtasks.mdin the same feature directory and did not create a duplicate specs folder.Key evidence after

/speckit.plan:Follow-up review cleanup
After the Copilot review, I pushed a follow up commit to harden the metadata validation path:
jq/python3parsing so parse failures return non-zero from the helper instead of interacting poorly withset -e.feature.jsonfallback path.AI disclosure
I used ChatGPT and GitHub Copilot to help investigate the issue, review test coverage and also helping me polish this description. I personally wrote the script changes, ran the repro, tests, and manual validation, and reviewed the final diff before submitting.